Skip to content

BUG: reject live RNG objects as Sensor seeds - #1174

Merged
Gui-FernandesBR merged 5 commits into
RocketPy-Team:developfrom
myungjunlee:fix/sensor-seed-json-serializable
Sep 9, 2026
Merged

BUG: reject live RNG objects as Sensor seeds#1174
Gui-FernandesBR merged 5 commits into
RocketPy-Team:developfrom
myungjunlee:fix/sensor-seed-json-serializable

Conversation

@myungjunlee

@myungjunlee myungjunlee commented Aug 16, 2026

Copy link
Copy Markdown

Summary

Sensor.__init__ hands the seed to numpy.random.default_rng, which also accepts Generator and BitGenerator objects. The sensor constructs, to_dict() emits the object verbatim, and the failure surfaces later at json.dumps():

import json
import numpy as np
from rocketpy.sensors import Barometer
from rocketpy._encoders import RocketPyEncoder

sensor = Barometer(sampling_rate=10, seed=np.random.default_rng(5))  # accepted
json.dumps(sensor.to_dict(), cls=RocketPyEncoder)
# TypeError: Object of type Generator is not JSON serializable

This is the case #1124 leaves behind. #1124 closed the SeedSequence half of #1087 by teaching RocketPyEncoder to write one out, which works because a SeedSequence is defined by its entropy and spawn key and still describes the stream after a round trip.

A Generator has no such description. Its state advances on every draw, so what to_dict() wrote would depend on when it ran, and a restored copy would not reproduce the stream the sensor used. There is nothing to serialize it to, so it is rejected at the constructor instead, where the caller can still see which argument was wrong.

What changes

  • Generator and BitGenerator seeds raise TypeError at construction.
  • seed is annotated as int | Sequence[int] | np.random.SeedSequence | None on every constructor that takes one — the type this issue names in its own wording — so the contract is stated where the argument is declared and not only in the docstring. The docstrings now match.
  • Nothing else. Ints, numpy ints, SeedSequence, None, and the sequences of ints default_rng accepts all behave exactly as they do on develop today.

Test plan

  • pytest tests/unit/sensors/ -q → 63 passed, including BUG: serialize numpy SeedSequence for sensor seeds (#1087) #1124's test_seedsequence_sensor_seed_is_json_serializable
  • pytest tests/unit -q → no new failures (the 4 test_sensitivity.py errors are a missing statsmodels in my environment and reproduce on a clean develop)
  • Added: Generator/BitGenerator rejected · SeedSequence and a sequence of ints explicitly still accepted · None/0/int/np.int64/2**128-1 still accepted and round trip

@thc1006 thc1006 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for two correctness gaps:

  1. RandomState is accepted by default_rng on NumPy >= 2.2, so the original “constructs successfully, fails later at JSON serialization” bug is still reachable on a supported RocketPy dependency version.
  2. Accepted mutable seed descriptors are stored by reference. A later mutation can make to_dict() serialize a seed that no longer describes the stream used to initialize the sensor. This affects list/ndarray seeds and SeedSequence instances backed by mutable entropy.

I would also align the annotation and error text with the actual array-like integer contract (np.integer and ndarray are accepted today), and make the round-trip tests compare the generated noise stream rather than only the stored seed/state.

The constructor-level rejection is the right direction; these changes would make the boundary complete rather than covering only two currently known live RNG classes.

Comment thread rocketpy/sensors/sensor.py Outdated
# entropy and spawn key; a live generator has no such description.
# Without this check the sensor builds fine and only fails at
# json.dumps(), far from the call that caused it.
if isinstance(seed, (np.random.Generator, np.random.BitGenerator)):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

np.random.default_rng also accepts np.random.RandomState starting with NumPy 2.2. RocketPy supports numpy>=1.23 with no upper bound, so this still leaves the same late-failure path on current NumPy: the sensor constructs, the RandomState remains live state, and RocketPyEncoder cannot encode it.

Could we reject RandomState here as well and add it to the parametrized rejection test? More generally, validating against the stable seed-descriptor contract (SeedSequence entropy inputs) would be less brittle than blacklisting whichever live RNG types default_rng happens to accept today.

@myungjunlee

Copy link
Copy Markdown
Author

Thanks — the RandomState gap is real, and it took the PR's own claim with it. Both correctness points reproduce here on numpy 2.4.6. Pushed two commits.

RandomState, and the shape of the check. You're right that blacklisting live types is the brittle half of the contract, and this is a good demonstration of why: RandomState is not a subclass of either Generator or BitGenerator, so the isinstance list walked straight past it.

default_rng(RandomState)         -> Generator
Accelerometer(seed=RandomState)  -> constructs
json.dumps(...)                  -> TypeError: Object of type RandomState is not JSON serializable

So the check now accepts descriptors instead of naming live types: None, an int (including np.integer), an array_like of ints (nested, ndarray of integer dtype, empty included), or a SeedSequence. Everything else is refused at the constructor. The live-RNG branch is kept ahead of it purely for the error message, since "your seed's state advances as noise is drawn" is more useful than "invalid seed type".

Worth noting for anyone tightening this later: default_rng accepts an empty sequence and bool, and both serialize, so the descriptor check has to let them through.

Annotation. Widened to what the check actually takes, via a SeedLike union in sensors/sensor.py. It is a real types.UnionType, so help() and inspect.signature() still expand it to int | numpy.integer | Sequence[int] | numpy.ndarray | SeedSequence | None — the alias only keeps the seven signatures inside the 88-column limit. Docstrings and the Raises section follow.

Round-trip tests. Agreed, comparing the stored seed proves too little. Added a test that draws from the restored sensor and compares the sequence against a fresh sensor built from the same seed, parametrized over int, np.int64, int sequence and SeedSequence. It goes through RocketPyDecoder, since a SeedSequence comes back as a plain dict without it.

Mutable seed descriptors. Confirmed, and it is worse than only the seed field:

seed = [1, 2, 3]
sensor = Accelerometer(sampling_rate=10, seed=seed)
seed[0] = 999
sensor.to_dict()["seed"]   # [999, 2, 3]

SeedSequence backed by mutable entropy does the same, and its serialized signature.hash stays at the old value while entropy changes, so the record is not merely stale but internally inconsistent.

I've left it out of this PR deliberately. self._seed = seed predates it and is untouched here, and the failure is a different one — this PR is about a seed that cannot be written down at all and fails loudly at json.dumps(), whereas aliasing writes a value that succeeds and is quietly wrong. A copy at assignment would fix it, but that changes what to_dict()["seed"] is seed means for every caller, which seems worth its own issue and its own tests rather than riding along here.

Local run: tests/unit/sensors 81 passed, ruff and pylint clean. CI on this fork is still waiting on workflow approval.

@myungjunlee

Copy link
Copy Markdown
Author

develop has moved two commits since I branched — neither touches a file this PR does, and it still merges clean, so there's no rebase to do.

No workflow has run on this PR at all — not on the review commits, not on the first one. The three check suites are still sitting at action_required with zero runs.

`Sensor.__init__` passes the seed straight to `numpy.random.default_rng`,
which also accepts `Generator` and `BitGenerator` objects. The sensor then
constructs successfully and stores the object on `self._seed`, where
`to_dict()` emits it verbatim, so the failure only surfaces later at
`json.dumps()`, far from the call that caused it.

RocketPy-Team#1124 closed the `SeedSequence` case in RocketPy-Team#1087 by teaching `RocketPyEncoder`
to write one out. That works because a `SeedSequence` is defined by its
entropy and spawn key, so it still describes the stream after a round trip.
A `Generator` has no such description: its state advances on every draw, so
whatever `to_dict()` wrote would depend on when it ran, and restoring it
would not reproduce the stream the sensor actually used.

Reject those two in the constructor instead, so the failure stays at the
call that caused it. Ints, numpy ints, `SeedSequence` and `None` are
untouched, as are the sequences of ints `default_rng` accepts and the
encoder already serializes, so no seed that works today is rejected.

Annotate `seed` on every constructor that takes one, with the type the
issue itself names, so the contract is stated where the argument is
declared rather than only in the docstring.
default_rng also accepts RandomState from NumPy 2.2 on, and RocketPy pins
no upper bound on numpy, so the previous isinstance list let it through to
the same late TypeError at json.dumps() that RocketPy-Team#1087 reported.

Check the stable half of the contract instead of enumerating the live types:
accept ints, array_like of ints and SeedSequence, and refuse the rest. A
seed kind numpy starts accepting later is now refused at construction rather
than reaching serialization.

Widen the annotation to the array_like integer contract the check actually
takes. It goes through a SeedLike union so the seven signatures stay inside
the line limit while help() and inspect.signature() still expand the members.
The existing round-trip tests assert on the stored seed value, which would
still pass for a seed that survives JSON without naming the stream the
original sensor used. Draw from the restored sensor instead and compare it
against a fresh one built from the same seed, across the four descriptor
kinds the constructor accepts.
@Gui-FernandesBR
Gui-FernandesBR force-pushed the fix/sensor-seed-json-serializable branch from 5c079d2 to 715bfa1 Compare September 9, 2026 01:24
@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.14%. Comparing base (7f2ad3f) to head (45b874e).

Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #1174      +/-   ##
===========================================
+ Coverage    91.13%   91.14%   +0.01%     
===========================================
  Files          131      131              
  Lines        17560    17576      +16     
===========================================
+ Hits         16003    16020      +17     
+ Misses        1557     1556       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Gui-FernandesBR and others added 2 commits September 8, 2026 23:13
The seed check accepted a nested sequence of ints, on the stated grounds
that "numpy accepts as entropy just the same". It does not: SeedSequence
raises TypeError for a nested sequence and ValueError for an array of two
or more dimensions. Older NumPy let the nested form through, which is why
this passed under Python 3.10 and failed under 3.14, where a newer NumPy is
resolved -- the acceptance was never portable, and RocketPy sets no upper
bound on the dependency.

So _is_int_array_like now takes a flat sequence only, and an ndarray only
at ndim <= 1. Both refusals name the seed, where the NumPy messages they
replace name neither it nor the argument that carried it.

The nested case moves out of test_int_array_like_seeds_are_accepted and
into a rejection test alongside a 2-D array, which the check would have
admitted for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Gui-FernandesBR
Gui-FernandesBR merged commit fa38028 into RocketPy-Team:develop Sep 9, 2026
9 checks passed
@Gui-FernandesBR Gui-FernandesBR linked an issue Sep 9, 2026 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sensor accepts a SeedSequence seed but cannot serialize it

3 participants